arcane-os 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.45.0
4
+
5
+ - Add public MarkdownMedia save, read, reference parsing and hydration helpers.
6
+ Store complete image records in application-owned DBOPFS tables outside raw
7
+ Markdown, with stable references and application-selected filenames. Preserve
8
+ JSONL/NDJSON singleton records across cold reads and backup restoration.
9
+ - Hydrate local images independently in shared Markdown views while preserving
10
+ raw Markdown, external URLs, source maps and saving. Observe cancellation and
11
+ individual failures without removing successful sibling images. Retain print
12
+ resources through afterprint; explicit PrintView preparation awaits local
13
+ media while native beforeprint remains synchronous.
14
+ - Apply no-retention semantics to entity-wide persistence choices as well as
15
+ request persist:false: complete input and response remain available to the
16
+ active operation, then stay outside retained transcript, recurring model
17
+ context, DBOPFS and memory extraction. Capture the accepted choice before
18
+ rendering waits and preserve original pending calls after temporary tool
19
+ continuations. Existing retained turns and saved history remain untouched;
20
+ structural sanitization applies to new durable writes only, without migration.
21
+ - Keep image model selection, credentials, storage selection and app orchestration
22
+ application-owned. No model/provider/default changes are introduced.
23
+
24
+ ## 0.44.0
25
+
26
+ - Add stateless `generateImages` through `arcane-os/ai/twin-cloud` with
27
+ caller-selected FAL Flux Schnell or Stable Diffusion 3.5 Large. Preserve
28
+ complete prompts and provider parameters, and return every image as a Blob
29
+ in provider order with declared media metadata.
30
+ - Expose semantic progress and complete diagnostic callbacks, asynchronous
31
+ job polling, and cancellation across credentials, callbacks, requests,
32
+ response bodies and media downloads. Submit a paid generation request once;
33
+ never automatically replay an ambiguous submission.
34
+ - Keep image model selection, credentials, display and storage with the
35
+ application. No global model default, saved-history migration, durable media
36
+ storage or change to existing chat and speech behavior is introduced.
37
+
3
38
  ## 0.43.0
4
39
 
5
40
  - Add reusable rendered printing through `createPrintView`, preserving the
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.43.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.45.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -105,6 +105,240 @@ export function normalizeAIRequestAbort(error){
105
105
  return normalized;
106
106
  }
107
107
 
108
+ /** Stateless image generation; model selection and provider parameters belong to the caller. */
109
+ export async function generateImages({
110
+ model,
111
+ prompt,
112
+ parameters = {},
113
+ twinKey,
114
+ getApiKey,
115
+ signal = null,
116
+ id = Date.now(),
117
+ onRequest = function observeImageRequest(){},
118
+ onResponse = function observeImageResponse(){},
119
+ onProgress = function observeImageProgress(){}
120
+ } = {}) {
121
+ if (signal?.aborted) throw normalizeAIRequestAbort(signal.reason);
122
+ const asynchronous = model === 'fal-ai/flux/schnell';
123
+ if (!asynchronous && model !== 'stable-diffusion-3.5-large') {
124
+ const error = new TypeError('TWiN generateImages requires a supported explicit image model.');
125
+ error.code = 'ARCANE_AI_IMAGE_MODEL_UNSUPPORTED';
126
+ throw error;
127
+ }
128
+ if (!is.string(prompt)) throw new TypeError('TWiN generateImages requires a prompt string.');
129
+ if (!parameters || !is.object(parameters) || is.array(parameters)) {
130
+ throw new TypeError('Image parameters must be an object of additional provider fields.');
131
+ }
132
+ if (Object.hasOwn(parameters, 'prompt') || (!asynchronous && Object.hasOwn(parameters, 'model'))) {
133
+ throw new TypeError('Supply the image prompt and model through their named arguments.');
134
+ }
135
+ const destination = asynchronous
136
+ ? 'https://inference.do-ai.run/v1/async-invoke'
137
+ : 'https://inference.do-ai.run/v1/images/generations';
138
+ const request = asynchronous
139
+ ? {model_id: model, input: {...parameters, prompt}}
140
+ : {...parameters, model, prompt};
141
+ // Observe the complete request without allowing a later callback to rewrite its payload.
142
+ const body = JSON.stringify(request);
143
+ const outputFormat = parameters.output_format;
144
+ const controller = new AbortController();
145
+ const operationSignal = controller.signal;
146
+ function cancelImageRequest() {controller.abort(signal.reason);}
147
+ signal?.addEventListener('abort', cancelImageRequest, {once: true});
148
+ if (signal?.aborted) cancelImageRequest();
149
+
150
+ function progress(stage, requestId) {
151
+ return settleImageOperation(
152
+ function publishImageProgress() {
153
+ return onProgress({stage, model, id, ...(requestId === undefined ? {} : {requestId})});
154
+ },
155
+ operationSignal
156
+ );
157
+ }
158
+
159
+ async function inference(url, options) {
160
+ const response = await settleImageOperation(
161
+ function requestImageInference() {return fetch(url, {...options, credentials: 'omit', signal: operationSignal});},
162
+ operationSignal
163
+ );
164
+ const result = await settleImageOperation(
165
+ function readImageInference() {
166
+ if (response.ok || response.headers.get('content-type')?.includes('application/json')) {
167
+ return response.json();
168
+ }
169
+ return response.text();
170
+ },
171
+ operationSignal
172
+ );
173
+ if (!response.ok) throw result;
174
+ const retryAfter = response.headers.get('retry-after');
175
+ const seconds = retryAfter?.trim() ? Number(retryAfter) : NaN;
176
+ const date = retryAfter?.trim() ? Date.parse(retryAfter) : NaN;
177
+ const delay = is.finite(seconds) && seconds >= 0 ? seconds * 1000
178
+ : is.finite(date) ? Math.max(0, date - Date.now()) : 1000;
179
+ return {result, delay};
180
+ }
181
+
182
+ try {
183
+ await progress('credentials');
184
+ const key = twinKey === undefined && is.function(getApiKey)
185
+ ? await settleImageOperation(getApiKey, operationSignal) : twinKey;
186
+ if (!is.string(key) || !key) {
187
+ const error = new Error('AI provider is not configured.');
188
+ error.code = 'AI_PROVIDER_NOT_CONFIGURED';
189
+ throw error;
190
+ }
191
+ const headers = {'Content-Type': 'application/json', Authorization: `Bearer ${key}`};
192
+ await settleImageOperation(
193
+ function observeImageSubmission() {
194
+ return onRequest(request, id, {operation: 'images', transport: 'http', destination});
195
+ },
196
+ operationSignal
197
+ );
198
+ await progress('requesting');
199
+ // A lost POST response can represent an accepted paid job. Never replay it.
200
+ let response = await inference(destination, {method: 'POST', headers, body});
201
+ let result = response.result;
202
+ if (asynchronous) {
203
+ const requestId = result?.request_id;
204
+ while (result?.status === 'QUEUED' || result?.status === 'IN_PROGRESS') {
205
+ if (!is.string(requestId) || !requestId) {
206
+ throw imageResultError('Image generation returned a pending job without a request_id.', result);
207
+ }
208
+ await progress(result.status === 'QUEUED' ? 'queued' : 'generating', requestId);
209
+ await waitForImageStatus(response.delay, operationSignal);
210
+ response = await inference(
211
+ `${destination}/${encodeURIComponent(requestId)}/status`,
212
+ {method: 'GET', headers}
213
+ );
214
+ result = response.result;
215
+ }
216
+ if (result?.status === 'FAILED') {
217
+ throw imageResultError('TWiN Cloud image generation failed.', result);
218
+ }
219
+ if (result?.status !== 'COMPLETED' && result?.status !== 'COMPLETE') {
220
+ throw imageResultError('TWiN Cloud returned an unsupported image job status.', result);
221
+ }
222
+ if (!is.array(result.output?.images)) {
223
+ if (!is.string(requestId) || !requestId) {
224
+ throw imageResultError('Image generation returned no result or request_id.', result);
225
+ }
226
+ response = await inference(`${destination}/${encodeURIComponent(requestId)}`, {method: 'GET', headers});
227
+ result = response.result;
228
+ if (result?.status !== 'COMPLETED' && result?.status !== 'COMPLETE') {
229
+ throw imageResultError('TWiN Cloud image generation did not return a completed result.', result);
230
+ }
231
+ }
232
+ }
233
+ const returnedImages = asynchronous ? result?.output?.images : result?.data;
234
+ if (!is.array(returnedImages)) {
235
+ throw imageResultError('TWiN Cloud returned no image collection.', result);
236
+ }
237
+ const images = returnedImages.map(function retainImageDescriptor(image) {return {...image};});
238
+ const format = result.output_format ?? outputFormat;
239
+ const requestId = asynchronous ? result.request_id : undefined;
240
+ await settleImageOperation(
241
+ function observeImageResult() {return onResponse(result, id, false);},
242
+ operationSignal
243
+ );
244
+ await progress('downloading', requestId);
245
+ const completed = await Promise.all(images.map(async function materializeImage(image) {
246
+ let blob;
247
+ const mediaType = image.content_type
248
+ ?? ({png: 'image/png', jpeg: 'image/jpeg', jpg: 'image/jpeg', webp: 'image/webp'}[format])
249
+ ?? 'application/octet-stream';
250
+ if (is.string(image.b64_json)) {
251
+ blob = await settleImageOperation(function decodeImageBase64() {
252
+ return new Blob([Uint8Array.from(atob(image.b64_json), function imageCode(character) {
253
+ return character.charCodeAt(0);
254
+ })], {type: mediaType});
255
+ }, operationSignal);
256
+ } else if (is.string(image.url) && image.url) {
257
+ // Media URLs have their own delivery authority. Never forward the inference key.
258
+ const download = await settleImageOperation(function downloadGeneratedImage() {
259
+ return fetch(image.url, {credentials: 'omit', signal: operationSignal});
260
+ }, operationSignal);
261
+ if (!download.ok) {
262
+ throw await settleImageOperation(function readImageDownloadError() {
263
+ return download.headers.get('content-type')?.includes('application/json')
264
+ ? download.json() : download.text();
265
+ }, operationSignal);
266
+ }
267
+ blob = await settleImageOperation(function readGeneratedImage() {return download.blob();}, operationSignal);
268
+ if (!blob.type) blob = new Blob([blob], {type: mediaType});
269
+ } else {
270
+ throw imageResultError('TWiN Cloud returned an image without data or a URL.', image);
271
+ }
272
+ return {
273
+ blob,
274
+ mediaType: blob.type || mediaType,
275
+ ...(image.width === undefined ? {} : {width: image.width}),
276
+ ...(image.height === undefined ? {} : {height: image.height})
277
+ };
278
+ }));
279
+ await progress('complete', requestId);
280
+ if (operationSignal.aborted) throw normalizeAIRequestAbort(operationSignal.reason);
281
+ return {images: completed};
282
+ } catch (error) {
283
+ if (isAIRequestAbort(error, operationSignal)) throw normalizeAIRequestAbort(error);
284
+ throw error;
285
+ } finally {
286
+ signal?.removeEventListener('abort', cancelImageRequest);
287
+ controller.abort('Image operation settled.');
288
+ }
289
+ }
290
+
291
+ function imageResultError(message, result) {
292
+ const error = new Error(message, {cause: result});
293
+ error.code = 'ARCANE_AI_INVALID_PROVIDER_RESULT';
294
+ return error;
295
+ }
296
+
297
+ /** Abort a wait even when a credential, callback, or host Fetch ignores the signal. */
298
+ function settleImageOperation(operation, signal) {
299
+ return new Promise(function settleCloudImageOperation(resolve, reject) {
300
+ let settled = false;
301
+ function finish(error, value, failed) {
302
+ if (settled) return;
303
+ settled = true;
304
+ signal.removeEventListener('abort', cancelled);
305
+ if (failed) reject(error);
306
+ else resolve(value);
307
+ }
308
+ function cancelled() {finish(normalizeAIRequestAbort(signal.reason), undefined, true);}
309
+ signal.addEventListener('abort', cancelled, {once: true});
310
+ if (signal.aborted) {
311
+ cancelled();
312
+ return;
313
+ }
314
+ try {
315
+ Promise.resolve(operation()).then(
316
+ function accepted(value) {finish(undefined, value, false);},
317
+ function rejected(error) {finish(error, undefined, true);}
318
+ );
319
+ } catch (error) {
320
+ finish(error, undefined, true);
321
+ }
322
+ });
323
+ }
324
+
325
+ async function waitForImageStatus(delay, signal) {
326
+ let remaining = delay;
327
+ do {
328
+ // Split only at the host timer range; retain the provider's entire delay.
329
+ const milliseconds = Math.min(remaining, 2147483647);
330
+ let timer;
331
+ try {
332
+ await settleImageOperation(function waitForImagePoll() {
333
+ return new Promise(function scheduleImagePoll(finish) {timer = setTimeout(finish, milliseconds);});
334
+ }, signal);
335
+ } finally {
336
+ clearTimeout(timer);
337
+ }
338
+ remaining -= milliseconds;
339
+ } while (remaining > 0);
340
+ }
341
+
108
342
  export function structuredOutputFormat(value = false){
109
343
  if(value === false || value === null || value === undefined){
110
344
  return null;
@@ -136,6 +136,130 @@ options; they do not add a key, model, browser state, or retained conversation.
136
136
  Retry warnings and observer failures use the shared console logger and preserve
137
137
  the complete original error.
138
138
 
139
+ ## Generate images with an application-selected model
140
+
141
+ `generateImages` is a separate stateless operation on the same public import.
142
+ The application owns the selected model, labels, prompt, provider parameters,
143
+ credential source, display, and any saved image. It does not instantiate the
144
+ browser AI singleton or change chat, speech, model preferences, or history.
145
+
146
+ ```javascript
147
+ import {generateImages} from 'arcane-os/ai/twin-cloud';
148
+
149
+ const controller = new AbortController();
150
+ const {images} = await generateImages({
151
+ model: 'stable-diffusion-3.5-large',
152
+ prompt: 'A moon-powered toaster launches croissants over purple craters.',
153
+ parameters: {n: 2, size: '1024x1024', output_format: 'png'},
154
+ getApiKey: applicationRuntime.getTwinKey,
155
+ signal: controller.signal,
156
+ onProgress({stage}) {
157
+ console.log(stage);
158
+ }
159
+ });
160
+
161
+ for (const image of images) {
162
+ await applicationRuntime.saveGeneratedImage(image.blob, image.mediaType);
163
+ }
164
+ ```
165
+
166
+ `applicationRuntime.getTwinKey` and `saveGeneratedImage` are caller-owned
167
+ functions in this example, not SDK APIs. Pass the credential function with any
168
+ required receiver already bound. Use `twinKey` instead when the application
169
+ already has its key. `getApiKey()` may return a key or a promise and is called
170
+ only when `twinKey` is `undefined`. An explicitly supplied empty key fails with
171
+ `AI_PROVIDER_NOT_CONFIGURED`; it does not select another credential source.
172
+ The example's model and parameters are application choices, not SDK defaults.
173
+
174
+ ```javascript
175
+ generateImages({
176
+ model,
177
+ prompt,
178
+ parameters,
179
+ twinKey,
180
+ getApiKey,
181
+ signal,
182
+ id,
183
+ onRequest,
184
+ onResponse,
185
+ onProgress
186
+ })
187
+ // Promise<{images: [{blob, mediaType, width?, height?}]}>
188
+ ```
189
+
190
+ `parameters` defaults to `{}`, `signal` to `null`, and `id` to `Date.now()`.
191
+ All diagnostic and progress callbacks are optional.
192
+
193
+ The SDK owns the route for these exact supported identifiers:
194
+
195
+ | Explicit model | Submission and result |
196
+ | --- | --- |
197
+ | `fal-ai/flux/schnell` | POST `/v1/async-invoke` with `{model_id:model,input:{...parameters,prompt}}`; pending jobs use GET `/v1/async-invoke/{request_id}/status`, then GET `/v1/async-invoke/{request_id}` when the completed status lacks `output.images`. |
198
+ | `stable-diffusion-3.5-large` | POST `/v1/images/generations` with `{...parameters,model,prompt}`; images come from the completed response's `data` array. |
199
+
200
+ Both routes use `https://inference.do-ai.run`. Missing or unsupported model
201
+ identifiers reject with `ARCANE_AI_IMAGE_MODEL_UNSUPPORTED`; there is no SDK
202
+ image model default. The prompt must be a string and is sent unchanged,
203
+ including whitespace. `parameters` is an object of additional provider fields;
204
+ the SDK neither supplies nor changes image counts, dimensions, quality, seeds,
205
+ or output limits. Supply the prompt only through `prompt`, and the synchronous
206
+ model only through `model`; duplicate reserved fields in `parameters` throw
207
+ `TypeError` instead of silently replacing one input. Provider-supported
208
+ parameter names and values remain that selected model's contract.
209
+
210
+ Every returned image is materialized in provider order. Base64 image data
211
+ becomes a `Blob`; returned URLs are fetched concurrently without an inference
212
+ Authorization header or browser credentials. The output's `mediaType` uses the
213
+ downloaded Blob's declared type, provider `content_type`, or a declared
214
+ `output_format` of `png`, `jpeg`/`jpg`, or `webp`. With no declared type or known
215
+ format, it is `application/octet-stream`; the SDK does not guess dimensions or
216
+ image metadata. `width` and `height` are included only when the provider supplies
217
+ them. The SDK saves nothing and creates no object URLs; the caller owns any
218
+ storage and the lifetime of display URLs it creates.
219
+
220
+ `onRequest(request,id,metadata)` runs once before submission with the complete
221
+ provider request. Metadata is `{operation:'images',transport:'http',destination}`.
222
+ `onResponse(response,id,false)` runs once with the complete final provider JSON
223
+ after successful generation and before image materialization. Intermediate
224
+ poll replies do not call it. These are explicit diagnostic callbacks: keep raw
225
+ protocol outside ordinary user status and durable chat history. Credentials are
226
+ transport-only and are absent from these payloads. The request and image
227
+ descriptors are captured before their diagnostic callbacks so observer edits do
228
+ not rewrite the operation's content.
229
+
230
+ `onProgress({stage,model,id,requestId?})` reports semantic stages: `credentials`,
231
+ `requesting`, `queued`, `generating`, `downloading`, and `complete`. Pending
232
+ stages can repeat; immediately completed jobs skip them. Async job stages
233
+ include `requestId` when present. Progress contains no image data or protocol
234
+ body. All three callbacks may return a promise; their settlement is awaited
235
+ and their failure rejects the operation. `onResponse` means generation returned
236
+ a final result, not that media downloads have finished. The resolved promise
237
+ delivers all materialized images; a failed download rejects instead of returning
238
+ an incomplete collection.
239
+
240
+ The image operation submits once and performs no automatic retry, including
241
+ after a network loss, HTTP `429`, body-read failure, or callback failure. A lost
242
+ POST response may correspond to an accepted paid job. This image-specific rule
243
+ does not alter chat or speech retry behavior. While a FAL job is `QUEUED` or
244
+ `IN_PROGRESS`, polling uses the provider's `Retry-After` delay when supplied,
245
+ otherwise one second. Completed jobs accept `COMPLETED` and the guide's
246
+ `COMPLETE` spelling. Failed or unusable job results reject with
247
+ `ARCANE_AI_INVALID_PROVIDER_RESULT` and the complete result in `error.cause`.
248
+ HTTP failures throw their complete parsed JSON or text body; transport,
249
+ decoding, and callback failures remain their original errors.
250
+
251
+ Calling `controller.abort()` cancels local credential/callback waits, requests,
252
+ body reads, polling timers, and downloads. It rejects with
253
+ `ARCANE_AI_REQUEST_ABORTED` and ignores later settlement from an operation that
254
+ does not honor the signal. A callback already running remains caller-owned;
255
+ the SDK invokes no subsequent callbacks after cancellation. Local cancellation
256
+ does not claim to cancel a remote paid job. Dispose the controller with the
257
+ owning page or operation; no global startup wait is required.
258
+
259
+ The route and response contracts follow DigitalOcean's official
260
+ [FAL inference guide](https://docs.digitalocean.com/products/inference/how-to/use-fal-models/)
261
+ and [multimodal inference guide](https://docs.digitalocean.com/products/inference/how-to/use-multimodal-inference/).
262
+
139
263
  ## Existing browser AI interface
140
264
 
141
265
  The following browser example uses the same managed imports as the
@@ -426,7 +426,8 @@ history stores each tool's required user-facing message as an ordinary
426
426
  call envelope or ID and therefore does not recreate a pending executable call
427
427
  after reload. `submitToolResult()` and `submitToolResults()` accept `status` as
428
428
  the public result term; `disposition` remains an accepted input spelling.
429
- When a submitted turn uses `persist:false`, Chat may show its cards while that
429
+ When a submitted turn uses `persist:false`, or its session's Chat entity has
430
+ `persist=false`, Chat may show its cards while that
430
431
  one operation is active, then removes them when the operation settles; neither
431
432
  the input nor response remains in the retained transcript or model context.
432
433
  This cleanup also applies to failed, cancelled, detached, or replaced-session
@@ -437,6 +438,15 @@ operation's nodes so a completion listener can start another turn safely.
437
438
  Submission code passes those card references directly when it creates them;
438
439
  reusing an operation identifier never selects earlier conversation cards for
439
440
  cleanup or timestamp updates.
441
+ The session's terminal `retained` boolean reports its actual commit decision;
442
+ Chat uses that decision instead of retroactively interpreting a flag changed
443
+ for a later request. A completion listener cannot erase an earlier settled
444
+ turn by changing the next operation's retention setting.
445
+ Chat carries a submission's no-retention choice into its copied session request
446
+ before any rendering wait, so re-enabling the entity before dispatch cannot
447
+ retain that temporary input. Existing falsy entity settings retain their
448
+ no-retention meaning; a compatible custom session without a Chat entity keeps
449
+ its existing default behavior.
440
450
  Persistent-turn recovery and existing saved history remain unchanged.
441
451
 
442
452
  For streaming sessions, every provisional structural card and the terminal
@@ -926,6 +936,16 @@ Native PDF and inaccessible embedded viewers require their own print command.
926
936
  was produced. Closed, destroyed, or unavailable previews return `false`;
927
937
  preparation failures reject and the visible control reports a concise failure.
928
938
 
939
+ Markdown previews resolve saved `arcane-media:` images through
940
+ [`MarkdownMedia.js`](runtime-modules.md#markdownmediajs) before connecting IMG
941
+ nodes. Text appears while independent image reads finish; failures leave the
942
+ complete text and successful images visible with a concise recovery status.
943
+ Explicit printing waits for local reads and decoding. Native Print reports
944
+ pending media because it cannot await them. Closing or replacing a preview
945
+ cancels display readiness without waiting for storage; URLs already retained
946
+ by a requested print remain until `afterprint`. Original Markdown and ordinary
947
+ external image URLs remain unchanged.
948
+
929
949
  `await close()` cancels pending presentation work and closes delete, file, and
930
950
  directory dialogs in that order. A settled preview keeps its rendered content,
931
951
  media, and printing available until the file dialog actually closes; refusing
@@ -1054,7 +1074,16 @@ Events: `markdown-document-ready`, `markdown-document-state`,
1054
1074
  `markdown-document-empty`, `markdown-document-error`, and
1055
1075
  `markdown-document-navigate`.
1056
1076
 
1057
- Shared dependencies: [`MD.js`](runtime-modules.md#mdjs).
1077
+ Saved local image references use
1078
+ [`MarkdownMedia.js`](runtime-modules.md#markdownmediajs). Text rendering and
1079
+ the existing rendered event do not wait for image reads. The optional
1080
+ `labels.mediaError` supplies concise visible failure text while complete
1081
+ diagnostics remain in the developer console; successful images and document
1082
+ text remain visible. Replacing, clearing, failing, or destroying the document
1083
+ releases its display resources without deleting stored media. Markdown source
1084
+ and ordinary external image URLs remain unchanged.
1085
+
1086
+ Shared dependencies: [`MD.js`](runtime-modules.md#mdjs), [`MarkdownMedia.js`](runtime-modules.md#markdownmediajs).
1058
1087
 
1059
1088
  ### Availability and normalization
1060
1089
 
@@ -1104,6 +1133,16 @@ when the browser dialog was requested. Completion of physical printing or PDF
1104
1133
  saving is not observable through this method. See
1105
1134
  [`PrintView.js`](runtime-modules.md#printviewjs).
1106
1135
 
1136
+ Saved `arcane-media:` image references are hydrated through
1137
+ [`MarkdownMedia.js`](runtime-modules.md#markdownmediajs) in detached rendered
1138
+ nodes. Source text, edit selection, source-map comments, ordinary image URLs,
1139
+ and the existing save flow remain unchanged. Image status has its own
1140
+ idle-hidden `media-status` part, separate from save status. Explicit printing
1141
+ waits for the current image reads/decoding, follows a replacement preview, and
1142
+ rechecks readiness before capture. Native Print reports still-pending media
1143
+ synchronously; it cannot wait or cancel the native dialog. Print-owned URLs
1144
+ outlive a changed or destroyed editor until `afterprint`.
1145
+
1107
1146
  `configure({fit:true,followPreview:true})` opts into the two independent
1108
1147
  behaviors. Both options default to `false`; omitted options retain their
1109
1148
  previous values during reconfiguration, and explicit `false` disables either.
@@ -1140,7 +1179,7 @@ lists, quotes, and tables follow their block, while invisible source positions
1140
1179
  use a nearby available rendered block rather than exact glyph geometry.
1141
1180
 
1142
1181
  Public CSS parts are `editor`, `title`, `toolbar`, `input`, `preview`,
1143
- `actions`, `status`, `print-action`, and `save-action`. Applications can make the title
1182
+ `actions`, `status`, `media-status`, `print-action`, and `save-action`. Applications can make the title
1144
1183
  borderless and compact through `::part(title)` while retaining its native
1145
1184
  editing behavior and visible keyboard focus. These parts are styling hooks,
1146
1185
  not slots or replacement controls.
@@ -96,6 +96,7 @@ own asynchronous work, cancellation, and backpressure.
96
96
  | [`Mail.js`](#mailjs) | esm | Builds complete reports and prefers the native mail capability with an explicit HTTP transport fallback. | Browser/native hybrid + cloud | Mail inputs/results normalized; transport failures mixed. |
97
97
  | [`MailOutbox.mjs`](#mailoutboxmjs) | esm | Persists complete mail reports before delivery and normalizes idempotent enqueue, retry, reconciliation, and invalid-record maintenance. | Browser/native WebView or compatible injected host | Complete records, full work, cancellation, and lifecycle states normalized; storage, lock, and delivery failures coded. |
98
98
  | [`MailTransport.mjs`](#mailtransportmjs) | esm | Sends one complete mail report to a normalized HTTP(S) endpoint. | Browser/server with fetch + cloud | Normalized endpoint and transport errors; remote detail preserved. |
99
+ | [`MarkdownMedia.js`](#markdownmediajs) | esm | Saves local Markdown images separately and resolves their stable references in rendered views. | Browser / supported native WebView | Complete image records, concurrent display reads, cancellation, and print-owned URL lifetime. |
99
100
  | [`MarkdownSpeech.js`](#markdownspeechjs) | esm | Removes repeated Markdown formatting marks from streamed narration. | Cross-host | Speech-only filtering; single marks and ordinary punctuation remain literal. |
100
101
  | [`Marked.min.js`](#markedminjs) | esm | Vendored Marked 18.0.5 Markdown lexer, parser, renderer, extension, and walk-token API. | Cross-host vendor module | Vendor-native Marked contract. |
101
102
  | [`MD.js`](#mdjs) | esm | Renders complete Markdown with Marked and optionally maps source blocks to rendered comment anchors. | Browser / native WebView | Complete raw and rendered Marked values; opt-in original-source offsets; parse errors vendor-native. |
@@ -2794,6 +2795,73 @@ import * as module from '/arcane/modules/Marked.min.js';
2794
2795
  console.log(Object.keys(module));
2795
2796
  ```
2796
2797
 
2798
+ ## MarkdownMedia.js
2799
+
2800
+ ### Overview
2801
+
2802
+ Stores complete images in the application's existing DBOPFS database while
2803
+ Markdown contains only a short, stable `arcane-media:` reference. Rendering
2804
+ resolves those local references into temporary display URLs. Importing the
2805
+ module starts no storage, network, provider, or rendering operation.
2806
+
2807
+ ### Public surface
2808
+
2809
+ Public package import: `arcane-os/modules/MarkdownMedia.js`.
2810
+
2811
+ - `await saveMarkdownMedia({blob,tableName='markdown-media',fileName})` writes
2812
+ `{mediaType,dataUrl}` as a JSON-compatible record and returns
2813
+ `{reference,tableName,fileName,mediaType}` only after the write completes.
2814
+ The default filename is a new UUID followed by `.json`. There is no abort
2815
+ option: accepted storage writes settle through the storage owner.
2816
+ - `parseMarkdownMediaReference(reference)` returns `{tableName,fileName}` for
2817
+ a local reference, or `null` for an ordinary URL. Malformed local addresses
2818
+ report their actual parsing error.
2819
+ - `await readMarkdownMedia(reference)` returns the complete `Blob`, using
2820
+ only local storage and data-URL decoding; stored strings are never fetched
2821
+ as network addresses.
2822
+ - `hydrateMarkdownMedia(root,{signal}={})` returns `{ready,destroy,retain}`
2823
+ immediately. It includes an IMG root and descendant IMG elements, removes
2824
+ local-reference `src` values synchronously, starts independent reads
2825
+ together, and assigns owned object URLs. `ready` includes image decoding.
2826
+ Use a detached fragment before connecting those images to the document.
2827
+
2828
+ Hydration preserves Markdown source, image descriptions, ordinary URLs, and
2829
+ surrounding content. Failed reads or decodes reject `ready` with an
2830
+ `AggregateError` whose complete `failures` array contains
2831
+ `{image,reference,reason}`; successful sibling images remain visible.
2832
+
2833
+ `destroy()` or abort settles display readiness promptly, prevents late display,
2834
+ and releases owned URLs. An already-started storage read remains observed and
2835
+ settles independently. `retain()` returns an idempotent release callback for
2836
+ printing; retained URLs survive destruction of the original view until every
2837
+ print owner releases them. Destruction never deletes stored images.
2838
+
2839
+ Applications own record names, entry association, cleanup policy, and which
2840
+ records their existing JSON backup/export includes. Reusing the same filename
2841
+ supports an application-selected save-only retry without another generation
2842
+ request. The helper performs no existing-data migration.
2843
+
2844
+ ### Availability and normalization
2845
+
2846
+ Browser or native WebView with the existing app-scoped DBOPFS owner, Blob,
2847
+ FileReader, object URLs, and IMG decoding. Encoding, storage, missing-record,
2848
+ parsing, and decoding errors remain observable. No Core capability is selected.
2849
+
2850
+ ### Example
2851
+
2852
+ ```javascript
2853
+ import {saveMarkdownMedia} from 'arcane-os/modules/MarkdownMedia.js';
2854
+
2855
+ const mural = new Blob([
2856
+ '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="80"><text x="10" y="45">Octopus parking only</text></svg>'
2857
+ ], {type:'image/svg+xml'});
2858
+ const {reference} = await saveMarkdownMedia({blob:mural});
2859
+ editor.insertMarkdown(`![Octopus parking only](${reference})`);
2860
+ ```
2861
+
2862
+ Here `editor` is the ready shared Markdown Editor. Its normal change/save flow
2863
+ still owns saving the Markdown; this operation saves the image record.
2864
+
2797
2865
  ## MD.js
2798
2866
 
2799
2867
  ### Overview
@@ -3071,6 +3139,9 @@ assistant content in configured model context and ChatEntity/DBOPFS history.
3071
3139
  That assistant-only opening survives reload and empty-chat maintenance without
3072
3140
  a fabricated user turn. A second opening rejects as `AI_CHAT_OPENING_EXISTS`;
3073
3141
  an unavailable durable ChatEntity rejects as `AI_CHAT_PERSISTENCE_UNAVAILABLE`.
3142
+ If retention is disabled while the opening is being prepared, that same error
3143
+ rolls back the opening before its append; it does not leave hidden model context.
3144
+ An opening write already accepted by DBOPFS still completes normally.
3074
3145
 
3075
3146
  `send()` accepts either
3076
3147
  `{message:{content,role:'user'|'tool',tool_call_id?,message?,name?,status?,persist},...}`
@@ -3086,6 +3157,20 @@ the retained transcript, memory extraction, or DBOPFS. A nonpersistent response
3086
3157
  therefore does not open a retained structural-tool continuation. A retained
3087
3158
  structural tool result must use the persistence choice captured by its matching
3088
3159
  assistant tool call.
3160
+ An explicitly nonretained tool-result continuation uses its matching pending
3161
+ call only for that operation, then rolls back without settling or changing the
3162
+ retained call. A later retained continuation can still settle it normally.
3163
+
3164
+ The entity-wide `session.chatEntity.persist=false` applies the same no-retention
3165
+ rule to new turns and direct entity additions; it is not a disk-only switch.
3166
+ The session captures that setting when it accepts a request and observes it
3167
+ again before accepting a durable append. Changing it later cannot bring back
3168
+ discarded inputs or responses. A write already accepted by DBOPFS finishes
3169
+ normally; toggling the setting does not undo that write or rewrite saved data.
3170
+ Completed `send()` and `stream()` responses include a session-owned `retained`
3171
+ boolean outside `message` and the provider payload, identifying whether the
3172
+ turn entered recurring history. Existing retained and stored records remain
3173
+ untouched: this boundary applies to new additions and performs no migration.
3089
3174
 
3090
3175
  `history()` returns provider-safe configured model context, including the
3091
3176
  system prompt and every complete ordinary visible committed turn. Only a
@@ -3263,11 +3348,16 @@ print-only surface expands scrollable content without editing the live screen.
3263
3348
 
3264
3349
  ### Public surface
3265
3350
 
3266
- Named `createPrintView({host,content,title,active,priority=0,signal,onError,retain})`
3351
+ Named `createPrintView({host,content,title,active,priority=0,signal,onError,retain,prepare})`
3267
3352
  returns `{print,destroy}`. `host` is a connected element; synchronous callbacks
3268
3353
  return the rendered content node, string title, and whether the view is active.
3269
3354
  Optional `retain()` returns a resource-release callback. It keeps owned media
3270
3355
  available until preparation is cancelled or the browser emits `afterprint`.
3356
+ Optional `prepare(signal)` may await view-owned readiness during an explicit
3357
+ print request. After it settles, current content and title are read again
3358
+ synchronously immediately before capture. A view whose content changes during
3359
+ preparation must keep its owned media retained and reject a still-pending
3360
+ snapshot through its synchronous `content()` callback.
3271
3361
 
3272
3362
  `await print()` selects that view, waits for accessible HTML frame, image, and
3273
3363
  font readiness, and requests the browser print dialog. It returns `false` when
@@ -3281,6 +3371,8 @@ on ties. File preview uses priority `1`; editor uses `0`. Native `beforeprint`
3281
3371
  is synchronous: it snapshots currently rendered content, cannot await a newly
3282
3372
  loading resource, and cannot cancel the browser's dialog. `onError(error)` or
3283
3373
  the developer console receives complete preparation failures.
3374
+ Native printing never invokes the asynchronous `prepare` callback; prepared
3375
+ views still receive the synchronous content/title read at capture.
3284
3376
 
3285
3377
  `destroy()` unregisters the view and cancels pending preparation. Once the
3286
3378
  dialog has been requested, its snapshot and retained media remain until